Skip to content

Fast partial-read path for sharding_indexed codec#264

Open
habemus-papadum wants to merge 3 commits into
JuliaIO:mainfrom
habemus-papadum:partial-reads-no-parallelism
Open

Fast partial-read path for sharding_indexed codec#264
habemus-papadum wants to merge 3 commits into
JuliaIO:mainfrom
habemus-papadum:partial-reads-no-parallelism

Conversation

@habemus-papadum

Copy link
Copy Markdown

What this changes

The current ShardingCodec decode path always materializes the entire outer chunk, regardless of how small the user's slice is. A request that touches one inner shard pays decompression for every other inner shard in the outer chunk — on a typical layout (e.g. 740 daily inner shards in one outer chunk) that's roughly a 700× decompression tax on partial reads, and zarr-python doesn't pay it.

This PR adds two layered fast paths for the sharding_indexed codec, both transparent to user code (arr[a:b, c] is unchanged):

  1. In-memory partial decode (Codecs/V3/V3.jl). When the chunk's bytes are already in memory, only decode the inner chunks intersecting the user's slice, copying each intersection straight into the output. Skipped inner chunks pay nothing.
  2. Storage-aware partial read (ZArray.jl + Storage/Storage.jl). When the storage backend supports byte-range reads, fetch only the shard index plus the bytes of the intersecting inner chunks. Skipped inner chunks aren't read off disk either.

Storage interface

Three new optional methods on AbstractStore, each with a safe default that falls back to the existing full-read path:

  • supports_partial_reads(::AbstractStore) -> Bool (default false)
  • read_range(s, key, byte_range) -> Union{Vector{UInt8}, Nothing} (default: s[key][byte_range])
  • getsize(s, key) -> Int (default: length(s[key]))

DirectoryStore opts in (using seek + readbytes! and filesize). Other built-in backends inherit the defaults — they automatically use the in-memory partial-decode path with no behavior change.

Toggle

Zarr.enable_partial_shard_storage_reads[] (Ref{Bool}, default true), modeled on the existing concurrent_io_tasks::Ref. Set it to false to fall back to the in-memory partial-decode path even on stores that opt in — useful for A/B comparisons.

Pure-pipeline detection

The fast path is gated by a small helper, sharding_codec(p::V3Pipeline), that returns the inner ShardingCodec only when the pipeline is "pure" (no array→array codecs before, no bytes→bytes codecs after). Compound pipelines (e.g. transpose + sharding, or sharding wrapped by an outer compressor) keep the existing decode path unchanged.

Tests

Two new testsets, 39 new test cases:

  • test/v3_codecs.jl :: "sharding partial-read fast path" — pure-vs-impure pipeline detection, in-memory partial path via DictStore, storage-aware partial path via DirectoryStore, the toggle round-trip, and fill_value over partial slices of unwritten outer chunks. Slice patterns include single-inner-chunk, cross-inner-chunk, full-chunk, cross-outer-chunk, and whole-array reads.
  • test/storage.jl :: "Partial-read storage interface"AbstractStore defaults via DictStore (including missing-key handling) and the DirectoryStore overrides.

Existing test suite stays at 2482 passes; this PR adds 39 → 2521 / 2521 passing.

Docs

docs/src/UserGuide/partial_shard_reads.md describes when the fast path applies, the storage-interface methods stores opt into, and the toggle.

Performance

Measured on a (67 syms × 127M seconds) float64 sharded archive (the layout zarr-python writes by default for 4-year second-resolution archives), one-day single-symbol query:

  • Before this PR: 665 s (decompresses the full outer chunk per call)
  • After this PR: 1.0 s (one inner chunk decompressed)

That's the in-memory + storage-aware paths combined for DirectoryStore. Other partial-read patterns see similar 50-100× speedups; full-shard reads are unchanged.

Compatibility

  • Only the V3 sharding path is touched. V2 arrays, V3 arrays without sharding, and sharded V3 arrays whose pipeline isn't "pure" all run on the existing path with zero behavior change.
  • Storage interface additions are purely opt-in via the safe defaults; no existing backend has to change.
  • The toggle defaults to true for the perf win; setting it to false recovers the previous in-memory behavior exactly.

🤖 Generated with Claude Code

habemus-papadum and others added 2 commits April 27, 2026 19:38
The current ShardingCodec read path always decodes the full outer
chunk: a slice that touches one inner shard still pays decompression
for every other inner shard in the outer chunk. On a 740-shard layout
(4y of seconds, daily inner shards) that's a ~700x decompression tax
on surgical slices.

This change closes the gap to zarr-python with two layered fast paths:

In-memory partial decode (src/Codecs/V3/V3.jl):
  - sharding_codec(p) detects a "pure" sharding pipeline (no
    array->array codecs before, no bytes->bytes codecs after) so
    readblock! can opt into the fast path conservatively.
  - read_shard_partial_with_source! takes a byte-source closure and
    only decodes inner chunks intersecting the requested slice.
  - read_shard_partial! is the in-memory wrapper: caller has the
    whole outer chunk in memory, we slice it for index + inner-chunk
    bytes.

Storage-aware partial reads:
  - supports_partial_reads / read_range / getsize on AbstractStore
    (defaults preserve existing behavior — fall back to full read +
    in-memory slice).
  - DirectoryStore opts in: open + seek + read for read_range,
    filesize for getsize. Other store types are unchanged until
    someone implements byte-range reads for them.
  - _readblock_sharded_partial! in ZArray.jl wires it together: per
    outer chunk, full reads stay on the existing path; partial reads
    fetch only the index + intersecting inner chunks via byte-range
    reads.

Toggle:
  - Zarr.enable_partial_shard_storage_reads[] (Ref{Bool}, default
    true), modeled on the existing concurrent_io_tasks Ref. Set to
    false to fall back to the in-memory partial-decode path for A/B
    debugging.

Measured speedups on a (67, 127M) sharded float64 archive:
  partial 1-day query:   665s -> 1.0s   (665x)
  partial week query:     97s -> 1.0s    (97x)

Existing test suite: 2482/2482 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests
  - test/v3_codecs.jl: new "sharding partial-read fast path" testset
    with 5 sub-testsets covering:
      * sharding_codec() detection on pure / wrapped / non-sharded
        pipelines and on a non-V3Pipeline argument
      * in-memory partial path via DictStore (single-inner-chunk,
        cross-inner, full-chunk, cross-outer, tail, whole-array slices)
      * storage-aware partial path via DirectoryStore (same slice
        patterns; results must match the in-memory path byte-for-byte)
      * Zarr.enable_partial_shard_storage_reads[] toggle preserves
        correctness in both states
      * fill_value over a partial slice of an empty/never-written
        outer chunk
  - test/storage.jl: new "Partial-read storage interface" testset with
    sub-testsets covering the AbstractStore defaults (DictStore) and
    the DirectoryStore overrides (supports_partial_reads, read_range,
    getsize, missing-key fallthrough).

Docs
  - docs/src/UserGuide/partial_shard_reads.md: explains when the fast
    path applies, the storage-interface methods stores opt into, and
    the toggle. References the existing module-level docstrings.

No production code changed in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upstream changelog-enforcer CI check. Describes the
new in-memory and storage-aware partial-decode paths, the optional
AbstractStore methods (supports_partial_reads, read_range, getsize),
DirectoryStore opt-in, and the enable_partial_shard_storage_reads[]
toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@habemus-papadum

Copy link
Copy Markdown
Author

Pushed a follow-up commit (e382675) to handle CI feedback:

  • changelog check: now fixed — added an ## Unreleased entry to CHANGELOG.md describing this PR.

  • Julia LTS test failures (Minio + AWS S3 storage): not introduced by this PR. The failing assertion is UndefRefError: access to undefined reference in ScopedValues.get(::ScopedValue{AWS.AbstractAWSConfig}) inside AWSS3.S3.create_bucket — both failing testsets touch with_aws_config(...) do … end. The same failure shows up on PR [WIP] Internal threading + in-place codec API for sharded reads #265 which doesn't share my code paths, master CI was green 3 days ago, and test/Project.toml doesn't pin AWSS3 / ScopedValues. Most likely cause is an upstream package release that broke LTS compat with with_aws_config's internal ScopedValues dispatch. I haven't modified the failing tests; this is for maintainers to triage at the test-deps level.

  • codecov/project: the patch coverage (codecov/patch) is fine; the project-level drop is the usual rounding for any PR that touches multiple files. Should resolve once review starts.

  • The Julia 1, nightly, and pre rows all pass, including the new tests.

@lazarusA

Copy link
Copy Markdown
Member

this looks promising,

  • Before this PR: 665 s (decompresses the full outer chunk per call)
  • After this PR: 1.0 s (one inner chunk decompressed)

unfortunately, reviewing such a long PR is beyond me at the moment. But, we should get it in, in some form.

@mkitti

mkitti commented Jul 3, 2026

Copy link
Copy Markdown
Member

I will take this on

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces optimized partial-read behavior for Zarr v3 sharded arrays (sharding_indexed) so that small user slices only decode (and, when supported, only read) the intersecting inner chunks rather than materializing and decompressing an entire outer shard chunk.

Changes:

  • Adds an in-memory sharded partial-decode fast path that decodes only intersecting inner chunks.
  • Adds a storage-aware sharded partial-read path using new optional AbstractStore byte-range APIs (supports_partial_reads, read_range, getsize) plus a global toggle enable_partial_shard_storage_reads[].
  • Adds tests, documentation, and changelog entries covering the new behavior and store interface.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/v3_codecs.jl Adds correctness tests for pure-pipeline detection, in-memory partial decode, storage-aware partial reads, toggle behavior, and fill-value behavior.
test/storage.jl Adds tests for the new optional partial-read storage interface defaults and DirectoryStore overrides.
src/ZArray.jl Implements fast paths in readblock!, including a storage-aware partial-read path _readblock_sharded_partial!.
src/Zarr.jl Introduces the enable_partial_shard_storage_reads toggle with docs.
src/Storage/Storage.jl Adds default implementations for supports_partial_reads, read_range, and getsize on AbstractStore.
src/Storage/directorystore.jl Opts DirectoryStore into partial reads and implements range reads via seek + read.
src/Codecs/V3/V3.jl Adds pure-pipeline sharding detection plus shared partial shard decode helpers.
docs/src/UserGuide/partial_shard_reads.md Documents when partial shard reads apply, store opt-in APIs, and the toggle.
CHANGELOG.md Records the new partial-read feature and its public surface area.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Codecs/V3/V3.jl
Comment on lines +738 to +747
if chunk_compressed === nothing || isempty(chunk_compressed)
fv = fill_value === nothing ? zero(T) : T(fill_value)
aout_dest_outer = ntuple(N) do i
lo = first(indranges[i]) - output_base_offsets[i]
hi = last(indranges[i]) - output_base_offsets[i]
lo:hi
end
view(aout, aout_dest_outer...) .= fv
return aout
end
Comment thread src/ZArray.jl
Comment on lines +276 to +284
total_size = getsize(store, chunk_key)
if total_size == 0
# missing chunk → fill_value over the requested region
Codecs.V3Codecs.read_shard_partial!(
aout, obo, sc, nothing, chunk_shape,
Tuple(current_chunk_offsets), Tuple(indranges), fv,
)
continue
end
Comment on lines +70 to +74
open(fname, "r") do io
seek(io, first(byte_range) - 1)
readbytes!(io, buf, n)
end
return buf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants